Skip to content

feat(authlib): Add ContentSource capability for guardrail plugins#393

Merged
huang195 merged 5 commits into
rossoctl:mainfrom
huang195:feat/content-source
May 10, 2026
Merged

feat(authlib): Add ContentSource capability for guardrail plugins#393
huang195 merged 5 commits into
rossoctl:mainfrom
huang195:feat/content-source

Conversation

@huang195

Copy link
Copy Markdown
Member

Summary

Adds a small capability interface so parser plugins can expose their inspectable text to guardrail plugins (PII scrubbers, jailbreak detectors, content classifiers, prompt-injection filters, etc.) without the guardrails having to import any specific parser package.

Today, a guardrail that wants to scan user-provided text has to branch on every protocol it might encounter:

if pctx.Extensions.Inference != nil { /* walk Messages, Completion, ToolCalls */ }
if pctx.Extensions.A2A != nil        { /* walk Parts, normalize role */ }
if pctx.Extensions.MCP != nil        { /* dig into Params["arguments"] map */ }

With this PR:

for _, src := range pctx.ContentSources() {
    for _, f := range src.Fragments() {
        if f.Role == contracts.RoleUser {
            scan(f.Text)
        }
    }
}

What's in it

New package authlib/contracts/ — dependency-free, defines:

type ContentSource interface {
    Fragments() []Fragment
}

type Fragment struct {
    Role string
    Text string
}

Plus standard role constants (RoleUser, RoleAssistant, RoleSystem, RoleTool, RoleToolArgs, RoleToolResult). Vocabulary is open — protocols that don't fit any value may emit their own role strings.

Fragments() implementations on existing extensions (authlib/pipeline/content.go), with compile-time assertions:

Extension Request-phase output Response-phase output
A2AExtension text/data parts tagged with message role (agentassistant); file parts skipped artifact as assistant
MCPExtension tools/call name as tool; each argument value as tool_args (JSON-stringified); control-plane RPCs return nil result content[] text items as tool_result
InferenceExtension messages keyed by role (tooltool_result) completion as assistant; tool calls emit name + args

Consumer helperpctx.ContentSources() walks Extensions.A2A / .MCP / .Inference and returns non-nil ones satisfying the interface. Forward-compatible: when a protocols-registry refactor lands, the body rewrites to iterate a map — callers don't notice.

Docsplugin-reference.md gets a new "Exposing content to guardrails" section with the role-mapping table, a consumer example, and guidance on when NOT to implement (structure-oriented guardrails). plugin-tutorial.md gets Step 8 showing the ~10-line opt-in. framework-architecture.md §4 cross-references.

What's NOT in it

  • No wire-format change. Fragments() is a Go method; JSON marshalling is unaffected.
  • No behavioral change for pipelines without guardrail plugins.
  • No abctl changes. cmd/abctl builds and tests unchanged — the method is available on the decoded Go types automatically.
  • No registry migration. Named slots stay put. This PR is useful regardless of whether the protocols-registry refactor ever lands.
  • No guardrail plugin yet. This PR adds the contract and producer side only. Guardrail plugins arrive in follow-ups.

Test plan

  • go build ./... in authlib, cmd/authbridge, cmd/abctl under GOWORK=off
  • go test -race ./... passes in all three modules
  • go vet ./... clean in all three modules
  • gofmt -l clean on all files touched by this PR
  • Table-driven tests cover: role normalization (A2A agentassistant, Inference tooltool_result), empty/nil edge cases, non-string argument JSON-stringification, control-plane RPC skip, ContentSources() enumeration and uniform iteration

Scope for follow-ups

  • A reference guardrail plugin demonstrating the consumption pattern end-to-end.
  • Optional Path field on Fragment for violation-report citations (deferred — current shape covers the 80% content-scanning case).
  • MutableContentSource for inline redaction (deferred — depends on chained-mutator work).

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

huang195 added a commit to huang195/kagenti-extensions that referenced this pull request May 10, 2026
Five small fixes from review of the ContentSource capability:

- pipeline/content.go (InferenceExtension.Fragments): switch from
  make([]Fragment, 0, cap) + "if len==0 return nil" dance to
  `var out []Fragment`. Matches the nil-when-empty idiom used by
  A2AExtension and MCPExtension; the cap hint isn't measurable on
  this path.

- pipeline/content.go (stringifyAny): log at DEBUG on json.Marshal
  error so the skip is observable in verbose runs rather than
  invisible. Tighten the docstring to call out the JSON-origin-data
  precondition for future callers whose values may not round-trip.

- pipeline/content.go (normalizeA2ARole): reword the comment about
  the "raw value" escape hatch. Consumers going through the
  ContentSource interface cannot reach *A2AExtension.Role without
  a type assertion back to the concrete type — the original
  wording overstated what's available to generic guardrails. New
  wording: A2A-aware consumers may type-assert; framework-generic
  guardrails treat the normalized value as authoritative.

- contracts/content.go (Fragment): add a forward-compat note on the
  struct recommending named-field initialization, so existing call
  sites and tests survive when fields are added (Path locator for
  violation citations, Kind hint for format-aware scanners).

- docs/plugin-tutorial.md (Step 8): half-sentence pointer to
  A2AExtension.Fragments as the role-normalization reference, so
  readers copy-pasting the minimal example know to remap native
  role vocabularies to contracts.Role* constants.

No behavior change. Tests still pass in authlib, cmd/authbridge,
and cmd/abctl under GOWORK=off; vet clean; gofmt clean.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195

Copy link
Copy Markdown
Member Author

Addressing review feedback in 548bd10. Five small fixes:

  1. InferenceExtension.Fragments: use var out []Fragment for nil-when-empty consistency with A2A/MCP implementations.
  2. stringifyAny: DEBUG log on marshal error + JSON-origin precondition in docstring.
  3. normalizeA2ARole: rewrote the comment — interface consumers can't reach .Role without type-asserting back to the concrete type, so the original "raw value escape hatch" wording was misleading.
  4. Fragment struct: added a forward-compat note recommending named-field initialization for when fields are added (e.g., Path for violation citations).
  5. Tutorial Step 8: half-sentence pointer to A2AExtension.Fragments as the role-normalization reference.

On review comment #5 (merge-order coordination with #392): acknowledged — the rebase is trivial in either direction. If #392 lands first, the authlib/validation import gets dropped from this PR's pipeline/context.go hunk (leaving authlib/contracts alone). If this PR lands first, #392's context.go hunk keeps the authlib/contracts line alongside its own removals. No code change needed here.

Comment thread authbridge/authlib/pipeline/content.go Outdated
}

if e.Result != nil {
if items, ok := e.Result["content"].([]any); ok {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm nervous about this type assertion and others. They skip invalid content. Perhaps if ok is false a counter should be updated, or a message logged, so developers have a way of seeing malicious non-MCP payloads?

}
}
}
return out

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MCP also has an isError field and errors can carry text. Should an error messages also be considered a fragment?

huang195 added 5 commits May 10, 2026 13:11
Introduce a small capability interface so parser plugins can expose
their inspectable text to guardrail plugins (PII scrubbers, jailbreak
detectors, content classifiers, etc.) without the guardrails having to
import any specific parser package.

New package: authlib/contracts/

  ContentSource interface {
      Fragments() []Fragment
  }

  Fragment struct {
      Role string // "user" | "assistant" | "system" | "tool" |
                  // "tool_args" | "tool_result" | ...
      Text string
  }

The contracts package is deliberately dependency-free so neither
producers nor consumers of the capability couple to each other.

Implementations on existing extensions (authlib/pipeline/content.go):

  A2AExtension       - text + data parts tagged with message role;
                       A2A's "agent" is normalized to "assistant" so
                       the vocabulary is uniform with Inference; final
                       artifact emitted as assistant on the response
                       phase; file parts skipped (not prose).
  MCPExtension       - tools/call name as role=tool; each argument
                       value as role=tool_args, JSON-stringified for
                       non-strings; response content items of
                       type=text as role=tool_result. Control-plane
                       RPCs (initialize, tools/list, etc.) return nil.
  InferenceExtension - messages keyed by their existing role (OpenAI
                       "tool" role remapped to "tool_result" to match
                       MCP semantics); completion as assistant; each
                       tool call emits name + arguments as separate
                       fragments.

Consumer surface (authlib/pipeline/context.go):

  pctx.ContentSources() []contracts.ContentSource

walks the named slots (Extensions.A2A / .MCP / .Inference) and returns
non-nil ones satisfying the interface. Guardrails iterate the result;
no framework knowledge of which protocols exist leaks into the
consumer.

Forward compatibility: when a protocols-registry refactor lands, the
helper's body rewrites to iterate a map - callers of
pctx.ContentSources() don't notice.

Docs:
  plugin-reference.md        - new "Exposing content to guardrails"
                               section with the role-mapping table
                               across A2A/MCP/Inference, a consumer
                               example, and guidance on when NOT to
                               implement (structure-oriented guardrails).
  plugin-tutorial.md         - Step 8 showing the 10-line opt-in.
  framework-architecture.md  - paragraph pointing to the capability
                               pattern.

Zero wire-format change. Zero behavioral change for pipelines without
guardrail plugins - ContentSources is never called when no consumer
exists.

Test plan:
- go build ./... and go test -race ./... pass in authlib,
  cmd/authbridge, and cmd/abctl under GOWORK=off
- go vet ./... passes in all three modules
- Table-driven tests in pipeline/content_test.go cover role
  normalization, empty/nil edge cases, non-string argument
  stringification, control-plane RPC skip, and ContentSources
  helper enumeration

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Five small fixes from review of the ContentSource capability:

- pipeline/content.go (InferenceExtension.Fragments): switch from
  make([]Fragment, 0, cap) + "if len==0 return nil" dance to
  `var out []Fragment`. Matches the nil-when-empty idiom used by
  A2AExtension and MCPExtension; the cap hint isn't measurable on
  this path.

- pipeline/content.go (stringifyAny): log at DEBUG on json.Marshal
  error so the skip is observable in verbose runs rather than
  invisible. Tighten the docstring to call out the JSON-origin-data
  precondition for future callers whose values may not round-trip.

- pipeline/content.go (normalizeA2ARole): reword the comment about
  the "raw value" escape hatch. Consumers going through the
  ContentSource interface cannot reach *A2AExtension.Role without
  a type assertion back to the concrete type — the original
  wording overstated what's available to generic guardrails. New
  wording: A2A-aware consumers may type-assert; framework-generic
  guardrails treat the normalized value as authoritative.

- contracts/content.go (Fragment): add a forward-compat note on the
  struct recommending named-field initialization, so existing call
  sites and tests survive when fields are added (Path locator for
  violation citations, Kind hint for format-aware scanners).

- docs/plugin-tutorial.md (Step 8): half-sentence pointer to
  A2AExtension.Fragments as the role-normalization reference, so
  readers copy-pasting the minimal example know to remap native
  role vocabularies to contracts.Role* constants.

No behavior change. Tests still pass in authlib, cmd/authbridge,
and cmd/abctl under GOWORK=off; vet clean; gofmt clean.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Previous fixup (548bd10) reworded the normalizeA2ARole comment to
describe type-assertion as the escape hatch for reading "the raw
value." That framing was wrong: it accepted the premise that the
interface hides role information consumers need, when the actual
design is that Fragment.Role IS the role information, in a uniform
vocabulary across every protocol.

Rewrite the comment to state the intent directly:

- Normalization to the standard vocabulary is the design goal, not
  a compromise. A guardrail compares f.Role == contracts.RoleUser
  once and works across A2A, MCP, and Inference.
- Fragment.Role is authoritative — no type-assertion needed in the
  common case.
- Type-assertion to *A2AExtension.Role is only appropriate for
  A2A-specialized tooling that wants the wire-level native value
  ("agent") verbatim, e.g., a protocol inspector. Framework-generic
  consumers should not do that.

No behavior change.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Add a forward-reference paragraph to normalizeA2ARole explaining why
this helper (and the corresponding Fragments methods on A2AExtension,
MCPExtension, InferenceExtension) lives in pipeline/content.go rather
than alongside its parser in plugins/.

Short version: Go requires methods on a type to be declared in the
type's own package, and the extension types currently live in
pipeline/ as named slots on Extensions. A planned protocols-registry
refactor will move the extension types into their parser packages
(removing the named slots); at that point the methods and helpers
move with them. Until then, the logical misplacement is marked in
the code so a future reader understands it's a known shape, not an
accident.

No behavior change.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
PR rossoctl#393 review raised two concerns about MCPExtension.Fragments:

1. Silent type-assertion skips hide malformed (potentially malicious)
   non-conforming MCP payloads. A client that sends something other
   than a map for Params["arguments"] or an array for Result["content"]
   gets skipped with no signal anywhere.

2. JSON-RPC-level errors carry a Message field that may contain
   inspectable content (leaked credentials in "invalid url: ...",
   stack traces, PII from failed DB lookups). Today guardrails
   never see it — the error channel is a gap in coverage.

Changes:

- Log at DEBUG on each unexpected-shape skip inside MCPExtension.Fragments:
  * Params["arguments"] present but not map[string]any
  * Result["content"] present but not []any
  * Result.content[i] present but not map[string]any
  Full counter/metric wiring would be heavier than the concern warrants;
  a DEBUG log is the right level for "operator debugging a weird client."

- Emit MCPError.Message as a role=tool_result fragment when Err is
  non-nil and Message is non-empty. A PII scrubber, credential
  detector, or content classifier now covers the error channel
  uniformly with normal tool output.

Also updates the MCP row in plugin-reference.md's role-mapping table
to note the tool_result role now covers both result text AND error
messages.

Tests: 5 new cases covering error-only, error-plus-result,
empty-error-skip, malformed-arguments-skip, and malformed-result-skip.

No behavior change for well-formed MCP traffic.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
huang195 force-pushed the feat/content-source branch from d08c9b2 to a2e331b Compare May 10, 2026 17:13
@huang195

Copy link
Copy Markdown
Member Author

Rebased onto current main (PR #392 merged — conflict was the trivial 1-line hunk in pipeline/context.go imports that we flagged ahead of time).

Addressing the two inline reviews in a2e331b:

"skip invalid content silently" — Now logs at DEBUG on each unexpected-shape skip inside MCPExtension.Fragments:

  • Params["arguments"] present but not map[string]any
  • Result["content"] present but not []any
  • Result.content[i] present but not a map

A full counter / metric would be heavier than the concern warrants — DEBUG is the right level for "operator debugging a weird client." If the project later adds structured metrics to the pipeline, wiring these counters in is additive.

"MCP errors can carry text — consider them fragments"MCPError.Message is now emitted as a role=tool_result fragment when Err is non-nil and Message is non-empty. A PII scrubber, credential detector, or content classifier now covers the error channel uniformly with normal tool output — closes the gap you flagged. plugin-reference.md updated to note the tool_result role covers both result text and error messages.

5 new test cases cover: error-only, error-plus-result, empty-error-skip, malformed-arguments-skip, malformed-result-skip.

@huang195
huang195 merged commit c0737cd into rossoctl:main May 10, 2026
17 checks passed
@huang195
huang195 deleted the feat/content-source branch May 10, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants